Verken geavanceerde dependency injection patronen in FastAPI voor het bouwen van schaalbare, onderhoudbare en testbare applicaties. Leer hoe je een robuuste DI container structureert.
FastAPI Dependency Injection: Geavanceerde DI Container Architectuur
FastAPI is, met zijn intuïtieve ontwerp en krachtige functies, een favoriet geworden voor het bouwen van moderne web API's in Python. Een van de belangrijkste sterke punten is de naadloze integratie met dependency injection (DI), waardoor ontwikkelaars losgekoppelde, testbare en onderhoudbare applicaties kunnen maken. Hoewel het ingebouwde DI-systeem van FastAPI uitstekend is voor eenvoudige use cases, profiteren complexere projecten vaak van een meer gestructureerde en geavanceerde DI container architectuur. Dit artikel onderzoekt verschillende strategieën voor het bouwen van een dergelijke architectuur, en biedt praktische voorbeelden en inzichten voor het ontwerpen van robuuste en schaalbare applicaties.
Inzicht in Dependency Injection (DI) en Inversion of Control (IoC)
Voordat we ingaan op geavanceerde DI container architecturen, laten we de fundamentele concepten verduidelijken:
- Dependency Injection (DI): Een ontwerppatroon waarbij afhankelijkheden aan een component worden verstrekt vanuit externe bronnen in plaats van intern te worden gecreëerd. Dit bevordert losse koppeling, waardoor componenten gemakkelijker te testen en opnieuw te gebruiken zijn.
- Inversion of Control (IoC): Een breder principe waarbij de controle over het creëren en beheren van objecten wordt omgekeerd – gedelegeerd aan een framework of container. DI is een specifiek type IoC.
FastAPI ondersteunt inherent DI via het dependency systeem. Je definieert afhankelijkheden als aanroepbare objecten (functies, klassen, enz.), en FastAPI lost ze automatisch op en injecteert ze in je endpoint functies of andere afhankelijkheden.
Voorbeeld (Basis FastAPI DI):
from fastapi import FastAPI, Depends
app = FastAPI()
# Dependency
def get_db():
db = {"items": []} # Simuleer een databaseverbinding
try:
yield db
finally:
# Sluit de databaseverbinding (indien nodig)
pass
# Endpoint met dependency injection
@app.get("/items/")
async def read_items(db: dict = Depends(get_db)):
return db["items"]
In dit voorbeeld is get_db een afhankelijkheid die een databaseverbinding biedt. FastAPI roept automatisch get_db aan en injecteert het resultaat (het db dictionary) in de read_items endpoint functie.
Waarom een Geavanceerde DI Container?
FastAPI's ingebouwde DI werkt goed voor eenvoudige projecten, maar naarmate applicaties complexer worden, biedt een meer geavanceerde DI container verschillende voordelen:
- Gecentraliseerd Dependency Management: Een dedicated container biedt een enkele bron van waarheid voor alle afhankelijkheden, waardoor het gemakkelijker wordt om de afhankelijkheden van de applicatie te beheren en te begrijpen.
- Configuratie en Lifecycle Management: De container kan de configuratie en lifecycle van afhankelijkheden afhandelen, zoals het creëren van singletons, het beheren van verbindingen en het verwijderen van resources.
- Testbaarheid: Een geavanceerde container vereenvoudigt het testen door je in staat te stellen eenvoudig afhankelijkheden te overschrijven met mock objecten of test doubles.
- Ontkoppeling: Bevordert een grotere ontkoppeling tussen componenten, waardoor afhankelijkheden worden verminderd en de onderhoudbaarheid van de code wordt verbeterd.
- Uitbreidbaarheid: Een uitbreidbare container stelt je in staat om naar behoefte aangepaste functies en integraties toe te voegen.
Strategieën voor het Bouwen van een Geavanceerde DI Container
Er zijn verschillende benaderingen voor het bouwen van een geavanceerde DI container in FastAPI. Hier zijn enkele veel voorkomende strategieën:
1. Het Gebruiken van een Dedicated DI Library (bijv. `injector`, `dependency_injector`)
Er zijn verschillende krachtige DI libraries beschikbaar voor Python, zoals injector en dependency_injector. Deze libraries bieden een uitgebreide set functies voor het beheren van afhankelijkheden, waaronder:
- Binding: Definieert hoe afhankelijkheden worden opgelost en geïnjecteerd.
- Scopes: Beheren van de lifecycle van afhankelijkheden (bijv. singleton, transient).
- Configuratie: Beheren van configuratie-instellingen voor afhankelijkheden.
- AOP (Aspect-Oriented Programming): Intercepten van methode-aanroepen voor cross-cutting concerns.
Voorbeeld met `dependency_injector`
dependency_injector is een populaire keuze voor het bouwen van DI containers. Laten we het gebruik ervan illustreren met een voorbeeld:
from dependency_injector import containers, providers
from fastapi import FastAPI, Depends
# Definieer afhankelijkheden
class Database:
def __init__(self, connection_string: str):
self.connection_string = connection_string
# Initialiseer databaseverbinding
print(f"Connecting to database: {self.connection_string}")
def get_items(self):
# Simuleer het ophalen van items uit de database
return [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]
class UserRepository:
def __init__(self, database: Database):
self.database = database
def get_all_users(self):
# Simuleer databaseverzoek om alle gebruikers op te halen
return [{"id": "user1", "name": "Alice"},{"id": "user2", "name": "Bob"}]
class Settings:
def __init__(self, database_url):
self.database_url = database_url
# Definieer container
class Container(containers.DeclarativeContainer):
config = providers.Configuration()
settings = providers.Singleton(Settings, database_url = config.database_url)
database = providers.Singleton(Database, connection_string=config.database_url)
user_repository = providers.Factory(UserRepository, database=database)
# Maak FastAPI app
app = FastAPI()
# Configureer container (vanuit een omgevingsvariabele)
container = Container()
container.config.database_url.from_env("DATABASE_URL", default="sqlite:///:memory:")
container.wire([__name__]) # maakt injectie van afhankelijkheden in FastAPI endpoints mogelijk
# Dependency voor FastAPI
def get_user_repository(user_repository: UserRepository = Depends(container.user_repository.provided)) -> UserRepository:
return user_repository
# Endpoint met behulp van geïnjecteerde dependency
@app.get("/users/")
async def read_users(user_repository: UserRepository = Depends(get_user_repository)):
return user_repository.get_all_users()
@app.on_event("startup")
async def startup_event():
# Container initialisatie
container.init_resources()
Uitleg:
- We definiëren onze afhankelijkheden (
Database,UserRepository,Settings) als reguliere Python klassen. - We maken een
Containerklasse die overerft vancontainers.DeclarativeContainer. Deze klasse definieert de afhankelijkheden en hun providers (bijv.providers.Singletonvoor singletons,providers.Factoryvoor het telkens maken van nieuwe instanties). - De regel
container.wire([__name__])maakt dependency injection in FastAPI endpoints mogelijk. - De functie
get_user_repositoryis een FastAPI dependency diecontainer.user_repository.providedgebruikt om de UserRepository instantie uit de container op te halen. - De endpoint functie
read_usersinjecteert deUserRepositorydependency. - De `config` stelt u in staat om de dependency configuraties te externaliseren. Het kan dan afkomstig zijn van omgevingsvariabelen, configuratiebestanden enz.
- De `startup_event` wordt gebruikt om de resources te initialiseren die in de container worden beheerd.
2. Het Implementeren van een Aangepaste DI Container
Voor meer controle over het DI proces, kun je een aangepaste DI container implementeren. Deze aanpak vereist meer inspanning, maar stelt je in staat om de container af te stemmen op je specifieke behoeften.
Basis Aangepaste DI Container Voorbeeld:
from typing import Callable, Dict, Type, Any
from fastapi import FastAPI, Depends
class Container:
def __init__(self):
self.dependencies: Dict[Type[Any], Callable[..., Any]] = {}
self.instances: Dict[Type[Any], Any] = {}
def register(self, dependency_type: Type[Any], provider: Callable[..., Any]):
self.dependencies[dependency_type] = provider
def resolve(self, dependency_type: Type[Any]) -> Any:
if dependency_type in self.instances:
return self.instances[dependency_type]
if dependency_type not in self.dependencies:
raise Exception(f"Dependency {dependency_type} not registered.")
provider = self.dependencies[dependency_type]
instance = provider()
return instance
def singleton(self, dependency_type: Type[Any], provider: Callable[..., Any]):
self.register(dependency_type, provider)
self.instances[dependency_type] = provider()
# Voorbeeld Afhankelijkheden
class PaymentGateway:
def process_payment(self, amount: float) -> bool:
print(f"Processing payment of ${amount}")
return True # Simuleer succesvolle betaling
class NotificationService:
def send_notification(self, message: str):
print(f"Sending notification: {message}")
# Voorbeeld Gebruik
container = Container()
container.singleton(PaymentGateway, PaymentGateway)
container.singleton(NotificationService, NotificationService)
app = FastAPI()
# FastAPI Dependency
def get_payment_gateway(payment_gateway: PaymentGateway = Depends(lambda: container.resolve(PaymentGateway))):
return payment_gateway
def get_notification_service(notification_service: NotificationService = Depends(lambda: container.resolve(NotificationService))):
return notification_service
@app.post("/purchase/")
async def purchase_item(payment_gateway: PaymentGateway = Depends(get_payment_gateway), notification_service: NotificationService = Depends(get_notification_service)):
if payment_gateway.process_payment(100.0):
notification_service.send_notification("Purchase successful!")
return {"message": "Purchase successful"}
else:
return {"message": "Purchase failed"}
Uitleg:
- De
Containerklasse beheert een dictionary van afhankelijkheden en hun providers. - De
registermethode registreert een afhankelijkheid met zijn provider. - De
resolvemethode lost een afhankelijkheid op door zijn provider aan te roepen. - De
singletonmethode registreert een afhankelijkheid en maakt er één instantie van. - FastAPI afhankelijkheden worden gemaakt met behulp van een lambda functie om afhankelijkheden op te lossen vanuit de container.
3. Het Gebruiken van FastAPI's `Depends` met een Factory Functie
In plaats van een volwaardige DI container, kun je FastAPI's Depends gebruiken in combinatie met factory functies om een zekere mate van dependency management te bereiken. Deze aanpak is eenvoudiger dan het implementeren van een aangepaste container, maar biedt nog steeds enkele voordelen ten opzichte van het rechtstreeks instantiëren van afhankelijkheden binnen endpoint functies.
from fastapi import FastAPI, Depends
from typing import Callable
# Definieer Afhankelijkheden
class EmailService:
def __init__(self, smtp_server: str):
self.smtp_server = smtp_server
def send_email(self, recipient: str, subject: str, body: str):
print(f"Sending email to {recipient} via {self.smtp_server}: {subject} - {body}")
# Factory functie voor EmailService
def create_email_service(smtp_server: str) -> EmailService:
return EmailService(smtp_server=smtp_server)
# FastAPI
app = FastAPI()
# FastAPI Dependency, gebruikmakend van factory functie en Depends
def get_email_service(email_service: EmailService = Depends(lambda: create_email_service(smtp_server="smtp.example.com"))):
return email_service
@app.post("/send-email/")
async def send_email(recipient: str, subject: str, body: str, email_service: EmailService = Depends(get_email_service)):
email_service.send_email(recipient=recipient, subject=subject, body=body)
return {"message": "Email sent!"}
Uitleg:
- We definiëren een factory functie (
create_email_service) die instanties maakt van deEmailServicedependency. - De
get_email_servicedependency gebruiktDependsen een lambda om de factory functie aan te roepen en een instantie vanEmailServicete leveren. - De endpoint functie
send_emailinjecteert deEmailServicedependency.
Geavanceerde Overwegingen
1. Scopes en Lifecycles
DI containers bieden vaak functies voor het beheren van de lifecycle van afhankelijkheden. Veelvoorkomende scopes zijn:
- Singleton: Er wordt één instantie van de afhankelijkheid gemaakt en hergebruikt gedurende de levensduur van de applicatie. Dit is geschikt voor afhankelijkheden die stateless zijn of een globale scope hebben.
- Transient: Er wordt telkens een nieuwe instantie van de afhankelijkheid gemaakt wanneer deze wordt opgevraagd. Dit is geschikt voor afhankelijkheden die stateful zijn of van elkaar geïsoleerd moeten worden.
- Request: Er wordt één instantie van de afhankelijkheid gemaakt voor elk binnenkomend verzoek. Dit is geschikt voor afhankelijkheden die de status moeten behouden binnen de context van een enkel verzoek.
De dependency_injector library biedt ingebouwde ondersteuning voor scopes. Voor aangepaste containers, moet je zelf de scope management logica implementeren.
2. Configuratie
Afhankelijkheden vereisen vaak configuratie-instellingen, zoals databaseverbindingsstrings, API keys en feature flags. DI containers kunnen helpen bij het beheren van deze instellingen door een gecentraliseerde manier te bieden om toegang te krijgen tot configuratiewaarden en deze te injecteren.
In het dependency_injector voorbeeld, staat de config provider configuratie vanuit omgevingsvariabelen toe. Voor aangepaste containers, kun je configuratie laden vanuit bestanden of omgevingsvariabelen en deze opslaan in de container.
3. Testen
Een van de belangrijkste voordelen van DI is verbeterde testbaarheid. Met een DI container, kun je eenvoudig echte afhankelijkheden vervangen door mock objecten of test doubles tijdens het testen.
Voorbeeld (Testen met `dependency_injector`):
import pytest
from unittest.mock import MagicMock
from dependency_injector import containers, providers
from fastapi import FastAPI, Depends
from fastapi.testclient import TestClient
# Definieer afhankelijkheden (hetzelfde als voorheen)
class Database:
def __init__(self, connection_string: str):
self.connection_string = connection_string
def get_items(self):
return [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]
class UserRepository:
def __init__(self, database: Database):
self.database = database
def get_all_users(self):
return [{"id": "user1", "name": "Alice"},{"id": "user2", "name": "Bob"}]
class Settings:
def __init__(self, database_url):
self.database_url = database_url
# Definieer container (hetzelfde als voorheen)
class Container(containers.DeclarativeContainer):
config = providers.Configuration()
settings = providers.Singleton(Settings, database_url = config.database_url)
database = providers.Singleton(Database, connection_string=config.database_url)
user_repository = providers.Factory(UserRepository, database=database)
# Maak FastAPI app (hetzelfde als voorheen)
app = FastAPI()
# Configureer container (vanuit een omgevingsvariabele)
container = Container()
container.config.database_url.from_env("DATABASE_URL", default="sqlite:///:memory:")
container.wire([__name__]) # maakt injectie van afhankelijkheden in FastAPI endpoints mogelijk
# Dependency voor FastAPI
def get_user_repository(user_repository: UserRepository = Depends(container.user_repository.provided)) -> UserRepository:
return user_repository
# Endpoint met behulp van geïnjecteerde dependency (hetzelfde als voorheen)
@app.get("/users/")
async def read_users(user_repository: UserRepository = Depends(get_user_repository)):
return user_repository.get_all_users()
@app.on_event("startup")
async def startup_event():
# Container initialisatie
container.init_resources()
# Test
@pytest.fixture
def test_client():
# Overschrijf de database dependency met een mock
database_mock = MagicMock(spec=Database)
database_mock.get_items.return_value = [{"id": 3, "name": "Test Item"}]
user_repository_mock = MagicMock(spec = UserRepository)
user_repository_mock.get_all_users.return_value = [{"id": "test_user", "name": "Test User"}]
# Overschrijf container met mock dependencies
container.user_repository.override(providers.Factory(lambda: user_repository_mock))
with TestClient(app) as client:
yield client
container.user_repository.reset()
def test_read_users(test_client: TestClient):
response = test_client.get("/users/")
assert response.status_code == 200
assert response.json() == [{"id": "test_user", "name": "Test User"}]
Uitleg:
- We maken een mock object voor de
Databasedependency met behulp vanMagicMock. - We overschrijven de
databaseprovider in de container met het mock object met behulp vancontainer.database.override(). - De test functie
test_read_itemsgebruikt nu de mock database dependency. - Na testuitvoering, reset het de overschreven dependency van de container.
4. Asynchrone Afhankelijkheden
FastAPI is gebouwd bovenop asynchrone programmering (async/await). Wanneer je werkt met asynchrone afhankelijkheden (bijv. asynchrone databaseverbindingen), zorg er dan voor dat je DI container en dependency providers asynchrone bewerkingen ondersteunen.
Voorbeeld (Asynchrone Dependency met `dependency_injector`):
import asyncio
from dependency_injector import containers, providers
from fastapi import FastAPI, Depends
# Definieer asynchrone dependency
class AsyncDatabase:
def __init__(self, connection_string: str):
self.connection_string = connection_string
async def connect(self):
print(f"Connecting to database: {self.connection_string}")
await asyncio.sleep(0.1) # Simuleer verbindingstijd
async def fetch_data(self):
await asyncio.sleep(0.1) # Simuleer database query
return [{"id": 1, "name": "Async Item 1"}]
# Definieer container
class Container(containers.DeclarativeContainer):
config = providers.Configuration()
database = providers.Singleton(AsyncDatabase, connection_string=config.database_url)
# Maak FastAPI app
app = FastAPI()
# Configureer container
container = Container()
container.config.database_url.from_env("DATABASE_URL", default="sqlite:///:memory:")
container.wire([__name__])
# Dependency voor FastAPI
async def get_async_database(database: AsyncDatabase = Depends(container.database.provided)) -> AsyncDatabase:
await database.connect()
return database
# Endpoint met behulp van geïnjecteerde dependency
@app.get("/async-items/")
async def read_async_items(database: AsyncDatabase = Depends(get_async_database)):
data = await database.fetch_data()
return data
@app.on_event("startup")
async def startup_event():
# Container initialisatie
container.init_resources()
Uitleg:
- De
AsyncDatabaseklasse definieert asynchrone methoden met behulp vanasyncenawait. - De
get_async_databasedependency is ook gedefinieerd als een asynchrone functie. - De endpoint functie
read_async_itemsis gemarkeerd alsasyncen wacht op het resultaat vandatabase.fetch_data().
Het Kiezen van de Juiste Aanpak
De beste aanpak voor het bouwen van een geavanceerde DI container is afhankelijk van de complexiteit van je applicatie en je specifieke eisen:
- Voor kleine tot middelgrote projecten: FastAPI's ingebouwde DI of een factory functie aanpak met
Dependskan voldoende zijn. - Voor grotere, complexere projecten: Een dedicated DI library zoals
dependency_injectorbiedt een uitgebreide set functies voor het beheren van afhankelijkheden. - Voor projecten die fijnmazige controle vereisen over het DI proces: Het implementeren van een aangepaste DI container kan de beste optie zijn.
Conclusie
Dependency injection is een krachtige techniek voor het bouwen van schaalbare, onderhoudbare en testbare applicaties. Hoewel FastAPI's ingebouwde DI systeem uitstekend is voor eenvoudige use cases, kan een geavanceerde DI container architectuur aanzienlijke voordelen bieden voor complexere projecten. Door de juiste aanpak te kiezen en gebruik te maken van de functies van DI libraries of het implementeren van een aangepaste container, kun je een robuust en flexibel dependency management systeem creëren dat de algehele kwaliteit en onderhoudbaarheid van je FastAPI applicaties verbetert.
Globale Overwegingen
Bij het ontwerpen van DI containers voor globale applicaties, is het belangrijk om het volgende te overwegen:
- Lokalisatie: Afhankelijkheden gerelateerd aan lokalisatie (bijv. taalinstellingen, datumnotaties) moeten worden beheerd door de DI container om consistentie tussen verschillende regio's te garanderen.
- Tijdzones: Afhankelijkheden die tijdzone conversies afhandelen, moeten worden geïnjecteerd om het hardcoden van tijdzone informatie te vermijden.
- Valuta: Afhankelijkheden voor valuta conversie en formattering moeten worden beheerd door de container om verschillende valuta's te ondersteunen.
- Regionale Instellingen: Andere regionale instellingen, zoals getalnotaties en adresnotaties, moeten ook worden beheerd door de DI container.
- Multi-tenancy: Voor multi-tenant applicaties, moet de DI container in staat zijn om verschillende afhankelijkheden voor verschillende tenants te bieden. Dit kan worden bereikt door scopes of aangepaste dependency resolutie logica te gebruiken.
- Compliance en Veiligheid: Zorg ervoor dat je dependency management strategie voldoet aan relevante data privacy regelgeving (bijv. GDPR, CCPA) en security best practices in verschillende regio's. Behandel gevoelige credentials en configuraties veilig binnen de container.
Door deze globale factoren te overwegen, kun je DI containers maken die goed geschikt zijn voor het bouwen van applicaties die in een globale omgeving werken.